昨天介紹如何使用 cloundinary 今天繼續介紹 page 部分。
register page 需要注意的是每當 user 上傳圖片時,需要即時顯示照片,然後當 form submit 時打 register api 並同時把圖片上傳到 cloundinary,然後根據 uploadImageToCloudinary return profile url 然後在 insert 到 user 的 photo 中。
uploadImageToCloudinary : 圖片上傳到 cloundinary ,並呼叫 cloundinary 的 upload api ,然後 api 會 return 的 info 如 interface UploadImageApiResponse 。
interface UploadImageApiResponse {
asset_id: string;
public_id: string;
version: number;
version_id: string;
signature: string;
width: number;
height: number;
format: string;
resource_type: string;
created_at: string;
tags: any[];
bytes: number;
type: string;
etag: string;
placeholder: boolean;
url: string;
secure_url: string;
folder: string;
access_mode: string;
existing: boolean;
}
handleRegisterProfile : 先呼叫 uploadImageToCloudinary 等他 return profile sourceURL 就 call register api。
api.auth.registerUser.useMutation : 等到 onSuccess 就導頁到 login page。
import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { useRouter } from 'next/router'
import React, { ChangeEvent, useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'react-toastify'
import { FileUpload } from '~/client/components/FileUpload'
import { FormInput } from '~/client/components/FormInput'
import { LoadingButton } from '~/client/components/LoadingButton'
import { useStore } from '~/client/store'
import { CreateUserFormSchema, createUserFormSchema } from '~/server/schema/user.schema'
import { api } from '~/utils/trpc'
function RegisterPage() {
const router = useRouter()
const { mutateAsync: registerUser, isLoading } = api.auth.registerUser.useMutation({
onSuccess: () => {
toast.success('success register user')
router.push('/login')
},
onError: (e) => {
toast.error(e.message)
}
})
const [avatar, setAvatar] = useState<string>()
const { setUPloadImage } = useStore()
const { register, handleSubmit, reset, formState: { errors }, getValues, watch } = useForm<CreateUserFormSchema>({
resolver: zodResolver(createUserFormSchema),
mode: 'onChange'
})
const onSubmitHandler = async (data: CreateUserFormSchema) => {
const { photo } = data
const url = await uploadImageToCloudinary(photo)
registerUser({
name: data.name,
password: data.password,
email: data.email,
photo: url || ''
})
}
const uploadImageToCloudinary = async (image: ((string | false | File) & (string | false | File | undefined)) | null) => {
if (!image || typeof image === 'string') return
let formdata = new FormData()
formdata.append("file", image);
formdata.append("upload_preset", process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET);
formdata.append("public_id", image.name);
formdata.append("api_key", process.env.NEXT_PUBLIC_CLOUDINARY_APIKEY);
formdata.append("tags", 'profile');
formdata.append("folder", getValues('name'));
setUPloadImage(true)
const result: UploadImageApiResponse = await fetch(`https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_NAME}/image/upload`, {
method: 'POST',
body: formdata,
redirect: 'follow'
})
.then(response => response.json())
.catch(error => console.log('error', error))
.finally(() => {
setUPloadImage(false)
})
return result.secure_url
}
const handleRegisterProfile = async (e: ChangeEvent<HTMLInputElement>) => {
await register('photo').onChange(e)
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => {
if (!reader.result) return
setAvatar(reader.result?.toString())
}
}
return (
<section className="py-8 bg-ct-blue-600 min-h-screen grid place-items-center">
<div className="w-full">
<h1 className="text-4xl xl:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
Welcome
</h1>
<h2 className="text-lg text-center mb-4 text-ct-dark-200">
Sign Up To Get Started!
</h2>
<form
onSubmit={handleSubmit(onSubmitHandler)}
className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
>
<FormInput
register={register}
error={errors.name}
label="Full Name"
name="name"
/>
<FormInput
register={register}
error={errors.email}
label='email'
name='email'
/>
<FileUpload
register={register}
name='photo'
error={errors.photo}
label='profile'
onChange={handleRegisterProfile}
/>
{(avatar && !errors.photo) && (
<div className='w-[100px] h-[100px] rounded-xl overflow-hidden relative'>
<img src={avatar} alt="" className='object-cover w-full h-full ' />
</div>
)}
<FormInput
register={register}
error={errors.password}
label='password'
name='password'
/>
<FormInput
register={register}
error={errors.passwordConfirm}
label='confirm password'
name='passwordConfirm'
/>
<span className="block">
Already have an account?{" "}
<Link href="/login" className="text-ct-blue-600">
Login Here
</Link>
</span>
<LoadingButton loading={isLoading} textColor="text-ct-blue-600">
Sign Up
</LoadingButton>
</form>
</div>
</section>
)
}
export default RegisterPage
完整 demo

註冊成功導入 login page

api.auth.loginUser : onSuccess 後 set access_token 到 useStore 中,然後導到 profile page。
import Link from 'next/link'
import React from 'react'
import { FormInput } from '~/client/components/FormInput'
import { LoadingButton } from '~/client/components/LoadingButton'
import { useForm } from 'react-hook-form'
import { loginUserSchema, LoginUserSchema } from '~/server/schema/user.schema'
import { zodResolver } from '@hookform/resolvers/zod'
import { api } from '~/utils/trpc'
import { toast } from 'react-toastify'
import { useRouter } from 'next/router'
import { useStore } from '~/client/store'
function LoginPage() {
const { setAccessToken, access_token } = useStore()
const router = useRouter()
const { register, handleSubmit, formState: { errors } } = useForm<LoginUserSchema>({
resolver: zodResolver(loginUserSchema),
mode: 'onChange'
})
const { mutateAsync: userLogin, isLoading } = api.auth.loginUser.useMutation({
onSuccess: (data) => {
toast.success('success login ')
setAccessToken(data.access_token)
router.push('/profile')
},
onError: (e) => {
toast.error(e.message)
}
})
const onSubmitHandler = async (data: LoginUserSchema) => {
userLogin(data)
}
return (
<section className="py-8 bg-ct-blue-600 min-h-screen grid place-items-center">
<div className="w-full">
<h1 className="text-4xl xl:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
Welcome Back
</h1>
<h2 className="text-lg text-center mb-4 text-ct-dark-200">
Login to have access
</h2>
<form
onSubmit={handleSubmit(onSubmitHandler)}
className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
>
<FormInput
register={register}
error={errors.email}
label="email"
name="email"
/>
<FormInput
register={register}
error={errors.password}
label="password"
name="password"
/>
<LoadingButton loading={isLoading} textColor="text-ct-blue-600">
Login
</LoadingButton>
<span className="block">
Need an account?{" "}
<Link href="/register" className="text-ct-blue-600">
Sign Up Here
</Link>
</span>
</form>
</div>
</section>
)
}
export default LoginPage

getMe : 每當進入 Profile page 時候都會先打 getMe 然後渲染 user info。setImage : 只要 user 成功 upload image ,同步更新 getMe 的 query dta 。
備註 : 在 getMe api 中用了refetchOnWindowFocus 跟refetchOnMount,其目的是不希望 user 每次切換頁面都重新打 api ,主要是優化 UX 部分,減少 loading indictor 。
import React, { ChangeEvent, useEffect, useMemo, useState } from 'react'
import { api } from '~/utils/trpc'
import { useStore } from '~/client/store';
import { uploadFormSchema } from '~/server/schema/user.schema';
import { toast } from 'react-toastify';
function ProfilePage() {
const apiContext = api.useContext()
const { mutateAsync: setProfileImageUrl } = api.user.setImage.useMutation({
onSuccess: () => {
apiContext.user.getMe.invalidate()
toast.success('success update user profile')
}
})
const { data } = api.user.getMe.useQuery(undefined, {
refetchOnWindowFocus: false,
refetchOnMount: false,
trpc: {
context: {
skipBatch: true
}
}
})
const user = useMemo(() => data?.data.user, [data])
const { setPageLoading, setAccessToken, access_token } = useStore()
const uploadImageToCloudinary = async (e: ChangeEvent<HTMLInputElement>) => {
const image = e.target.files?.[0]
if (!image) return
const validateResult = uploadFormSchema.safeParse({ photo: e.target.files })
if (!validateResult.success) return
let formdata = new FormData()
formdata.append("file", image);
formdata.append("upload_preset", process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET);
formdata.append("public_id", image.name);
formdata.append("api_key", process.env.NEXT_PUBLIC_CLOUDINARY_APIKEY);
formdata.append("tags", 'profile');
formdata.append("folder", user?.name as string);
setPageLoading(true)
const result: UploadImageApiResponse = await fetch(`https://api.cloudinary.com/v1_1/${process.env.NEXT_PUBLIC_CLOUDINARY_NAME}/image/upload`, {
method: 'POST',
body: formdata,
redirect: 'follow',
headers: { "Content-Type": "multipart/form-data" }
})
.then(response => response.json())
.catch(error => console.log('error', error))
.finally(() => {
setPageLoading(false)
})
setProfileImageUrl({ url: result.secure_url ?? 'default.png' })
}
return (
<>
<section className="bg-ct-blue-600 min-h-screen pt-20">
<div className="max-w-4xl mx-auto bg-ct-dark-100 rounded-md h-min-[20rem] flex justify-center items-center gap-4 p-4">
<div className='flex-1 flex flex-col items-center justify-center gap-4'>
<div className=' w-[200px] h-[200px] rounded-full overflow-hidden relative bg-red-500/20'>
<img className='object-cover w-full h-full ' src={user?.photo || 'default.png'} />
</div>
<div>
<label htmlFor='upload' className='cursor-pointer text-sm mb-2 text-slate-500 hover:text-white mr-4 py-2 px-4 rounded-full border-0 text-sm font-semibold bg-violet-200 text-violet-700 hover:bg-violet-400'>
edit
</label>
<input
id="upload"
type="file"
className='hidden'
onChange={uploadImageToCloudinary}
accept='image/*'
multiple={false}
/>
</div>
</div>
<div className='flex-1'>
<p className="text-5xl font-semibold">Profile Page</p>
<div className="mt-8">
<p className="mb-4">ID: {user?.id}</p>
<p className="mb-4">Name: {user?.name}</p>
<p className="mb-4">Email: {user?.email}</p>
<p className="mb-4">Role: {user?.role}</p>
</div>
</div>
</div>
</section>
</>
)
}
export default ProfilePage

但這時你發現你成功登入後,再回到 profile page 時候你會發現,user 還是可以進入,所以我們需要一個 validate 機制讓 user 不要造訪該頁面。
這邊我打算用 hook 方式來檢驗~
useRefreshToken 中主要會是透過打 refreshAccessToken api 方式去驗證 laccessToken 是否過期或是有沒有 validate ,error 就登出 success 就保留頁面狀態並同步更新 accessToken。
import { useRouter } from "next/router"
import { useEffect } from "react"
import { useStore } from "~/client/store"
import { api } from "~/utils/trpc"
export const useRefreshToken = () => {
const { setAccessToken } = useStore()
const router = useRouter()
const { setPageLoading } = useStore()
const { data, isLoading, isError } = api.auth.refreshAccessToken.useQuery(undefined, {
trpc: {
context: {
skipBatch: true
}
}
})
useEffect(() => {
if (!data?.access_token) return
setAccessToken(data?.access_token)
}, [data?.access_token])
useEffect(() => {
setPageLoading(isLoading)
}, [isLoading])
useEffect(() => {
if (!isError) return
router.push('/login')
setAccessToken('')
}, [isError])
}
最後在 ProfilePage 加上 useRefreshToken 功能就完成摟~
function ProfilePage() {
useRefreshToken()
//..
https://github.com/Danny101201/refetch-token/tree/main
✅ 前端社群 :
https://lihi3.cc/kBe0Y